package darknet.core.impl.net;

import darknet.application.Application;
import darknet.application.base.spec.BaseApplication;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.net.InetAddress;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.UnrecoverableKeyException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;

import darknet.core.impl.friend.FriendManagerImpl;
import darknet.application.chat.impl.ChatApplicationImpl;
import darknet.core.spec.net.Message;
import darknet.core.spec.net.NetworkManager;
import darknet.core.spec.net.Node;
import darknet.core.spec.net.Protocol;
import darknet.core.spec.net.StreamableMessage;
import darknet.application.chat.spec.ChatApplication;
import darknet.application.filesharing.impl.FileSharingApplicationImpl;
import darknet.application.filesharing.spec.FileSharingApplication;
import darknet.core.spec.net.NodeState;
import darknet.core.spec.net.Response;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Timer;

/**
 * A network manager implementation.
 * 
 * @author yoyo
 */
public class NetworkManagerImpl implements NetworkManager {

    /** Friend manager. */
    private FriendManagerImpl friendManager;

    /** Port to listen. */
    private int port;

    /** External address of the node. */
    private InetAddress externalAddress;

    /** Mapping nodeName --> node */
    private Map<String, NodeImpl> nodes;

    /** All application manager */
    private Map<Protocol, Application> applications;

    /** Server socket factory (SSL) */
    private SSLServerSocketFactory ssf;

    /** Socket factory (SSL) */
    private SSLSocketFactory sf;

    /** Listener Task */
    private ListenerRunnable listeningTask;

    /**
     * Initialise a new instance of a network manager.
     * 
     * @param friendManager The friend manager to use
     * @param port The port to listen
     * @param externalAddress
     * @param nodes The nodes known. If null, a new nodes will be create.
     */
    @SuppressWarnings("unchecked")
    public NetworkManagerImpl(FriendManagerImpl friendManager, int port, 
	    InetAddress externalAddress, Map<String, NodeImpl> nodes)
            throws IOException {
        
	// Check parameters
	if (friendManager == null) {
	    throw new IllegalArgumentException("friendManager is null");
	}
	this.friendManager = friendManager;

	friendManager.setNetworkManager(this);
	if (port <= 0) {
	    throw new IllegalArgumentException("port is invalid");
	}
	this.port = port;

	if (externalAddress == null) {
	    throw new IllegalArgumentException("external address is null");
	}
	this.externalAddress = externalAddress;

        if (nodes == null) {
             this.nodes = new HashMap<String, NodeImpl>();
	} else {
            this.nodes = nodes;
            for (String nodeName : nodes.keySet()) {
                NodeImpl node = nodes.get(nodeName);
                node.setState(NodeState.OFFLINE);
            }
        }

	// Create application list.
	this.applications = new HashMap<Protocol, Application>();

	// Add applications
        // TODO Load applications via plugin detection, not directly into code.
        BaseApplication base = new BaseApplicationImpl(this);
	ChatApplication chat = new ChatApplicationImpl(this);
        FileSharingApplication fileSharing
                = new FileSharingApplicationImpl(this);

        applications.put(base.getProtocol(), base);
	applications.put(chat.getProtocol(), chat);
        applications.put(fileSharing.getProtocol(), fileSharing);

	// Initialise factories.
	initialiseFactories();

	// start listener service.
        startListen();

        // Start the pinger thread.
        PingerTimerTask pingTask = new PingerTimerTask(friendManager, this,
                base);
        Timer pinger = new Timer("Pinger");
        pinger.schedule(pingTask, 0L, 5 * 1000);
    }

    /**
     * Initialise SSLServerSocketFactory and SSLSocketFactory.
     */
    private void initialiseFactories() {

	try {

	    KeyManagerFactory kmf = KeyManagerFactory.getInstance(
		    KeyManagerFactory.getDefaultAlgorithm());

	    kmf.init(friendManager.getKeyStore(),
		    friendManager.getPassword().toCharArray());

	    TrustManagerFactory tmf = TrustManagerFactory.getInstance(
		    TrustManagerFactory.getDefaultAlgorithm());
	    tmf.init(friendManager.getKeyStore());

	    SecureRandom random = new SecureRandom();

	    SSLContext context = SSLContext.getInstance("TLS");
	    context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), random);

	    this.ssf = context.getServerSocketFactory();
	    this.sf = context.getSocketFactory();

	} catch (NoSuchAlgorithmException e) {
	    throw new RuntimeException(e);
	} catch (UnrecoverableKeyException e) {
	    throw new RuntimeException(e);
	} catch (KeyStoreException e) {
	    throw new RuntimeException(e);
	} catch (KeyManagementException e) {
	    throw new RuntimeException(e);
	}
    }

    /**
     * Start listener service.
     * @throws IOException 
     */
    private void startListen() throws IOException {

        // Get a server socket (SSL).
	SSLServerSocket server = (SSLServerSocket) ssf.createServerSocket(port);
	server.setNeedClientAuth(true);

        // Start the listener thread.
        listeningTask = new ListenerRunnable(server, this);
        Thread worker = new Thread(listeningTask);
        worker.setName("Listener Thread");
        worker.start();
    }


    @Override
    public Application getApplication(Protocol protocol) {
	return applications.get(protocol);
    }

    @Override
    public List<Protocol> getProtocols() {
	return new ArrayList<Protocol>(applications.keySet());
    }

    @Override
    public void receive(StreamableMessage message) throws IOException {

	// Update old node information.
        Node updatedNode = new NodeImpl(
                message.getWrapped().getFrom().getName(),
                message.getWrapped().getFrom().getAddress(), 
                message.getWrapped().getFrom().getPort(), NodeState.ONLINE);
        update(updatedNode);

	// Get the application able to hand the given message.
	Application application = getApplication(
		message.getWrapped().getHeader().getProtocol());
	if (application.getHandler().canHandle(message.getWrapped())) {

	    application.getHandler().handle(message);

	} else {
	    assert false : "Application is supposed able to handle a " +
	    "message, but it can't";
	}
    }

    @Override
    public Response send(Message message) throws IOException {
        
	// Make a new Streamable message.
	Socket sock = getSocket(message.getTo().getName());
	StreamableMessage streamMessage = new StreamableMessageImpl(
		    message, sock.getInputStream(), sock.getOutputStream());

	// Get the application able to send the given message.
	Application application = getApplication(
		message.getHeader().getProtocol());
	if (application.getSender().canSend(message)) {

	    return application.getSender().send(streamMessage);

	} else {
	    assert false : "Application is supposed able to send a " +
	    "message, but it can't";
	}

        return null;
    }

    @Override
    public void update(Node node) {

	if (!nodes.containsKey(node.getName())) {

	    // Add the new node.
	    nodes.put(node.getName(), new NodeImpl(node.getName(), 
		    node.getAddress(), node.getPort(), node.getState()));

	} else {

	    // Just update the node infos.
	    NodeImpl toUpdate = nodes.get(node.getName());
	    toUpdate.setAddress(node.getAddress());
	    toUpdate.setPort(node.getPort());
            toUpdate.setState(node.getState());
	}

	save();
    }

    @Override
    public Node myNode() {
	return new NodeImpl(friendManager.getMe().getName(), 
		externalAddress, port, NodeState.ONLINE);
    }

    @Override
    public Node getNode(String nodeName) {
        return myNode().getName().equals(nodeName)
                ? myNode() : nodes.get(nodeName);
    }

    @Override
    public List<Node> getOnlineNodes() {

        List<Node> onlineNodes = new LinkedList<Node>();

        for (String nodeName : nodes.keySet()) {

            if (nodes.get(nodeName).getState().equals(NodeState.ONLINE)) {
                onlineNodes.add(nodes.get(nodeName));
            }
        }

        return onlineNodes;
    }

    @Override
    public void synchronise() throws IOException {
        updateSSLContext();
    }
    
    private void updateSSLContext() throws IOException {

        initialiseFactories();
        listeningTask.killMe();
        startListen();
    }

    /**
     * Save network manager informations.
     */
    private void save() {

	File nodeListFile = new File(
		friendManager.getWorkingDirectory().getAbsolutePath()
		+ File.separator 
		+ friendManager.getMe().getName() + ".nodelist");
        
	try {

	    ObjectOutputStream out = new ObjectOutputStream(
		    new FileOutputStream(nodeListFile));
	    out.writeObject(nodes);
	    out.flush();
            out.close();

	} catch (FileNotFoundException e) {
	    throw new RuntimeException(e);
	} catch (IOException e) {
	    throw new RuntimeException(e);
	}
    }

    /**
     * Get a (SSL) Socket, or null if friend name isn't exist.
     * 
     * @param nodeName The node name to connect.
     * @return
     */
    private Socket getSocket(String nodeName) throws IOException {

	Node node = getNode(nodeName);

	if(node == null) {
	    return null;
	}

	return (SSLSocket) sf.createSocket(node.getAddress(), node.getPort());
    }
}
